Skip to content

feat: unified IdP group mapping (OIDC + SCIM)#61

Merged
TerrifiedBug merged 9 commits intomainfrom
feat/unified-group-mapping
Mar 7, 2026
Merged

feat: unified IdP group mapping (OIDC + SCIM)#61
TerrifiedBug merged 9 commits intomainfrom
feat/unified-group-mapping

Conversation

@TerrifiedBug
Copy link
Copy Markdown
Owner

Summary

  • Introduces ScimGroup model — lightweight record for SCIM protocol compliance (stable IDs), decoupled from Team
  • SCIM Groups endpoints no longer create teams — they create ScimGroup records instead
  • SCIM group membership operations (PATCH add/remove, PUT sync) now resolve through the shared group mapping config (oidcTeamMappings) to create/remove TeamMember records in the correct teams with the correct roles
  • New shared helpers in group-mappings.ts: loadGroupMappings, getMappingsForGroup, applyMappedMemberships, removeMappedMemberships
  • Removes resolveScimRole (superseded by mapping helpers)
  • Renames "OIDC Team & Role Mapping" UI label to "IdP Group Mappings" since the config is now shared between OIDC and SCIM
  • Updates authentication docs to document both provisioning paths

How it works

Path Trigger Team assignment
OIDC-only User signs in Groups claim → mapping config → upsert TeamMembers
SCIM + OIDC IdP pushes sync SCIM group membership → mapping config → upsert TeamMembers. OIDC login refreshes on sign-in

Unmapped SCIM groups are acknowledged (SCIM protocol satisfied) but create no team memberships.

Test plan

  • Configure group mappings: map IdP group "admins" → Team "Engineering" with ADMIN role
  • SCIM sync from IdP — verify ScimGroup records created (not teams)
  • SCIM adds member to "admins" group — verify TeamMember created in "Engineering" with ADMIN role
  • SCIM removes member — verify TeamMember removed
  • OIDC login with groups claim — verify team assignment still works
  • Unmapped SCIM group — verify no team created, SCIM returns 200/201
  • Settings UI shows "IdP Group Mappings" label

@github-actions github-actions bot added feature documentation Improvements or additions to documentation and removed feature labels Mar 7, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 7, 2026

Greptile Summary

This PR decouples SCIM group state from the Team model by introducing a lightweight ScimGroup record, and wires both SCIM and OIDC group provisioning through a single shared group-mappings.ts service. The architecture change is well-motivated and the implementation addresses every critical issue raised in previous review rounds:

  • Role downgrade fixedapplyMappedMemberships uses a ROLE_RANK guard and only ever upgrades an existing role, never overwrites a higher one.
  • Stale mappings on rename fixed — both PATCH and PUT re-call getMappingsForGroup after a displayName update so subsequent member operations use the correct mapping.
  • Cascading deletes fixed — DELETE no longer touches TeamMember; adopted POST now correctly returns the freshly-updated ScimGroup record.
  • Role ambiguity fixedresolveScimRole is removed in favour of explicit per-group mappings.

Key remaining behavioral limitation: SCIM member remove and full-replace PUT are both additive-only. The code is clear and well-commented about why (no provenance field on TeamMember), but the PR description's test plan still includes "SCIM removes member — verify TeamMember removed" — a test that will never pass. Deprovisioning is deferred until the user's next OIDC login. Operators must be informed that SCIM-sourced access revocation is eventual, not immediate, and the test plan should be corrected to match the implementation's intentional design.

Confidence Score: 3/5

  • Safe to merge for structural improvements; operators must understand that SCIM access revocation is eventual (next OIDC login), not immediate.
  • All critical correctness bugs from previous reviews are addressed (role downgrades, stale mappings, cascading deletes). The architecture is clean. However, the PR description's test plan contradicts the implementation's intentional design decision to not remove team members via SCIM (by design, no provenance tracking). This gap must be communicated in documentation and the test plan must be corrected before merge. Score is held at 3 rather than 4 because this behavioral limitation — while correctly implemented — contradicts the stated test expectations.
  • src/app/api/scim/v2/Groups/[id]/route.ts — test plan documentation correction required before merge

Sequence Diagram

sequenceDiagram
    participant IdP as Identity Provider
    participant SCIM as SCIM Endpoint
    participant GM as group-mappings.ts
    participant DB as PostgreSQL

    IdP->>SCIM: POST /Groups {displayName, externalId, members?}
    SCIM->>DB: findUnique ScimGroup (by displayName)
    alt Group exists
        SCIM->>DB: update ScimGroup (externalId if changed)
        SCIM-->>IdP: 200 OK (updated record)
    else New group
        SCIM->>DB: create ScimGroup
        SCIM-->>IdP: 201 Created (no member provisioning)
    end

    IdP->>SCIM: PATCH /Groups/{id} ops:[add members]
    SCIM->>GM: loadGroupMappings()
    GM->>DB: findUnique SystemSettings.oidcTeamMappings
    GM-->>SCIM: GroupMapping[]
    SCIM->>GM: getMappingsForGroup(allMappings, displayName)
    loop each member userId
        SCIM->>GM: applyMappedMemberships(tx, userId, groupMappings)
        loop each mapping
            GM->>DB: findUnique TeamMember
            alt No existing membership
                GM->>DB: create TeamMember (mapped role)
            else Existing role is lower
                GM->>DB: update TeamMember (upgrade role only)
            end
        end
    end
    SCIM-->>IdP: 200 OK

    IdP->>SCIM: PATCH /Groups/{id} ops:[remove members]
    Note over SCIM: Remove is a no-op (no provenance tracking)<br/>Access revoked only on next OIDC login
    SCIM-->>IdP: 200 OK (membership retained until next OIDC login)

    IdP->>SCIM: PUT /Groups/{id} {displayName, members}
    SCIM->>GM: loadGroupMappings() + getMappingsForGroup()
    Note over SCIM: Additive-only sync — adds present members, never removes
    SCIM-->>IdP: 200 OK

    IdP->>SCIM: DELETE /Groups/{id}
    SCIM->>DB: delete ScimGroup (TeamMembers NOT cascaded)
    SCIM-->>IdP: 204 No Content
Loading

Last reviewed commit: 5f6d8ef

- POST: return updated adopted record instead of stale pre-update object
- PUT: change from destructive full-sync to additive-only member sync
  to prevent cross-group deprovisioning in multi-group scenarios
- DELETE: remove team member cascade that would wipe all members from
  mapped teams regardless of membership provenance
@TerrifiedBug
Copy link
Copy Markdown
Owner Author

@greptile review.

┌──────────────────────┬─────────────────────────────────┬────────────────────────────────┐
│ Finding │ Fix │ Impact │
├──────────────────────┼─────────────────────────────────┼────────────────────────────────┤
│ DELETE cascade wipes │ Removed teamMember.deleteMany │ Prevents mass deprovisioning │
│ all members │ cascade entirely │ when one SCIM group is deleted │
├──────────────────────┼─────────────────────────────────┼────────────────────────────────┤
│ PUT full-sync │ Changed to additive-only using │ Users from other │
│ removes cross-group │ applyMappedMemberships │ groups/OIDC/manual assignment │
│ members │ │ stay intact │
├──────────────────────┼─────────────────────────────────┼────────────────────────────────┤
│ POST returns stale │ Capture and return adopted from │ IdP sees current state, │
│ adopted record │ update │ preventing re-create conflicts │
└──────────────────────┴─────────────────────────────────┴────────────────────────────────┘

- PATCH member remove is now a no-op: without membership provenance
  tracking, removing would silently revoke access granted by other
  groups, OIDC, or manual assignment (same safeguard as DELETE/PUT)
- Remove removeMappedMemberships helper (no longer used)
- Process displayName rename before member ops and re-resolve mappings,
  preventing stale mapping context when rename + member ops are batched
@TerrifiedBug
Copy link
Copy Markdown
Owner Author

@greptile fixed.

PATCH remove cross-group data │ Made remove a no-op with comment; deleted │
│ loss │ removeMappedMemberships │
├────────────────────────────────┼────────────────────────────────────────────────────────┤
│ Stale mapping on rename + │ DisplayName rename now re-resolves groupMappings │
│ member batch │ immediately

…rships

- PUT handler now re-resolves groupMappings after displayName rename,
  matching the fix already applied to PATCH
- applyMappedMemberships only upgrades roles, never downgrades — prevents
  a lower-role group sync from overwriting a higher role granted by
  another group, OIDC, or manual assignment
@TerrifiedBug
Copy link
Copy Markdown
Owner Author

@greptile fixed

Finding 1 (PUT stale mappings): Same pattern we fixed in PATCH — re-resolve after rename.
Legitimate.

Finding 2 (role downgrade): Real issue. If "admins" maps to Engineering/ADMIN and "developers"
maps to Engineering/EDITOR, syncing "developers" would overwrite ADMIN→EDITOR. Fix: only
upgrade, never downgrade.

Comment on lines +96 to 100
// Member remove is intentionally a no-op. Without tracking which
// group granted each TeamMember, removing here would silently
// revoke access still legitimately granted by other groups, OIDC,
// or manual assignment. Memberships reconcile on next OIDC login.
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SCIM deprovisioning is silently non-functional — contradicts test plan

The code comment documents the no-op as intentional, which is a valid safety tradeoff. However the PR description's test plan states:

SCIM removes member — verify TeamMember removed

This test will always pass the protocol layer (the IdP gets a 200 OK) but will fail the intent — the TeamMember record is never removed by SCIM. Access is only revoked when the user next signs in via OIDC and their groups claim no longer includes this group.

This behaviour gap has real operator impact:

  • An admin removes a user from a sensitive IdP group expecting immediate access revocation
  • VectorFlow acknowledges the remove with 200 OK
  • The user retains team access until their next OIDC login (which could be days or never, if they have an active session)

The test plan item should either be updated to document this limitation, or a comment should be added to the docs describing that SCIM-sourced deprovisioning is eventual (OIDC-login-gated), not immediate. Operators need to know to communicate this to their security teams when evaluating the integration.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/api/scim/v2/Groups/[id]/route.ts
Line: 96-100

Comment:
SCIM deprovisioning is silently non-functional — contradicts test plan

The code comment documents the no-op as intentional, which is a valid safety tradeoff. However the PR description's test plan states:

> SCIM removes member — verify TeamMember removed

This test will always pass the protocol layer (the IdP gets a `200 OK`) but will **fail the intent** — the `TeamMember` record is never removed by SCIM. Access is only revoked when the user next signs in via OIDC and their groups claim no longer includes this group.

This behaviour gap has real operator impact:
- An admin removes a user from a sensitive IdP group expecting immediate access revocation
- VectorFlow acknowledges the remove with `200 OK`
- The user retains team access until their next OIDC login (which could be days or never, if they have an active session)

The test plan item should either be updated to document this limitation, or a comment should be added to the docs describing that SCIM-sourced deprovisioning is eventual (OIDC-login-gated), not immediate. Operators need to know to communicate this to their security teams when evaluating the integration.

How can I resolve this? If you propose a fix, please make it concise.

@TerrifiedBug TerrifiedBug merged commit d7424d1 into main Mar 7, 2026
12 checks passed
@TerrifiedBug TerrifiedBug deleted the feat/unified-group-mapping branch March 7, 2026 21:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant